import QuantLib as ql
import pandas as pd
= ql.Date(29, ql.October, 2021)
today = today ql.Settings.instance().evaluationDate
Different approaches to numerical Theta
The idea of calculating the Theta numerically seems a simple one; change the time to maturity (which, in the case of QuantLib, means moving the evaluation date) while keeping everything else the same, and reprice the instrument. However, there are different ways of “keeping everything else the same”, and they lead to slightly different results. Let’s look at them.
Setting a baseline
For the purposes of this notebook, we’ll look at a floating-rate bond on Euribor. For brevity, we’ll use a single curve for forecasting Euribor fixings and for discounting, and we’ll boostrap it over a set of interest-rate swaps. The argument I’ll make would remain the same if we were to use two different curves, as we should, or if we used a more diverse set of instruments for bootstrapping (including, for instance, a few interest-rate futures). Here is the construction of the curve: note that we’re not specifying its reference date explicitly, but as a number of business days and a calendar (in this case, 0 days, meaning that the reference date of the curve should equal the global evaluation date).
= [
swap_data "1Y", 0.137),
("2Y", 0.409),
("3Y", 0.674),
("5Y", 1.004),
("8Y", 1.258),
("10Y", 1.359),
("12Y", 1.420),
("15Y", 1.509),
("20Y", 1.574),
("25Y", 1.586),
("30Y", 1.579),
("35Y", 1.559),
("40Y", 1.514),
("45Y", 1.446),
("50Y", 1.425),
(
]
= [
swap_helpers
ql.SwapRateHelper(/ 100.0,
quote
ql.Period(tenor),
ql.TARGET(),
ql.Annual,
ql.Following,
ql.Thirty360(ql.Thirty360.BondBasis),
ql.Euribor6M(),
)for tenor, quote in swap_data
]
= ql.PiecewiseKrugerZero(
swap_curve 0,
ql.TARGET(),
swap_helpers,
ql.Actual360(), )
Here are the resulting zero rates at the curve nodes, corresponding to the maturities of the input swaps.
= pd.DataFrame(swap_curve.nodes(), columns=["node", "rate"])
todays_rates format({"rate": "{:.4%}"}) todays_rates.style.
node | rate | |
---|---|---|
0 | October 29th, 2021 | 0.1350% |
1 | November 2nd, 2022 | 0.1350% |
2 | November 2nd, 2023 | 0.4017% |
3 | November 4th, 2024 | 0.6624% |
4 | November 2nd, 2026 | 0.9901% |
5 | November 2nd, 2029 | 1.2446% |
6 | November 3rd, 2031 | 1.3470% |
7 | November 2nd, 2033 | 1.4085% |
8 | November 3rd, 2036 | 1.5007% |
9 | November 4th, 2041 | 1.5667% |
10 | November 2nd, 2046 | 1.5751% |
11 | November 2nd, 2051 | 1.5625% |
12 | November 2nd, 2056 | 1.5346% |
13 | November 2nd, 2061 | 1.4751% |
14 | November 2nd, 2066 | 1.3871% |
15 | November 2nd, 2071 | 1.3621% |
Next, we build the bond. As I mentioned, we’re passing the same handle to the index for forecasting and to the engine for discounting; this is just to avoid repeating code in the rest of the notebook. We should use two different curves instead.
= ql.RelinkableYieldTermStructureHandle(swap_curve) curve_handle
= ql.Euribor6M(curve_handle)
index 7, ql.May, 2021), 0.01)
index.addFixing(ql.Date(
= ql.MakeSchedule(
schedule =ql.Date(11, ql.May, 2021),
effectiveDate=ql.Date(11, ql.May, 2025),
terminationDate=ql.Semiannual,
frequency
)
= ql.FloatingRateBond(
bond =3,
settlementDays=10_000,
faceAmount=schedule,
schedule=index,
index=ql.Thirty360(ql.Thirty360.BondBasis),
paymentDayCounter=ql.Following,
paymentConvention
)
bond.setPricingEngine(ql.DiscountingBondEngine(curve_handle))
The price of the bond will work as the reference against which variations (and thus the Theta) will be measured.
= bond.cleanPrice()
P0 P0
99.98927601247395
Same market quotes
One way of “keeping everything else the same” is to simply move the evaluation date to the next business day; since the current date is a Friday, we’ll jump three days to the next Monday.
= ql.TARGET().advance(today, 1, ql.Days)
next_day next_day
Date(1,11,2021)
= next_day ql.Settings.instance().evaluationDate
Now, if we ask the bond for its price, the bond will reach for the curve and this will cause the latter to re-bootstrap. The reference date will move to the new evaluation date, the maturities of the input swaps will be recalculated accordingly, and the corresponding zero rates will be recalculated so that the curve once again reprices exactly the quoted swap rates—that have not changed. The bond price will change accordingly, and we can estimate the Theta (the Theta per day, to be exact) as the difference between the new price and the reference one.
bond.cleanPrice()
99.98208800779048
= bond.cleanPrice() - P0
theta_per_day theta_per_day
-0.0071880046834706945
We can also check the nodes of the new curve and compare them with the old ones. The dates have moved as expected, and some of the zero rates have changed.
= pd.DataFrame(swap_curve.nodes(), columns=["node", "rate"])
new_rates =1).style.format(
pd.concat([todays_rates, new_rates], axis"rate": "{:.4%}"}
{ )
node | rate | node | rate | |
---|---|---|---|---|
0 | October 29th, 2021 | 0.1350% | November 1st, 2021 | 0.1350% |
1 | November 2nd, 2022 | 0.1350% | November 3rd, 2022 | 0.1350% |
2 | November 2nd, 2023 | 0.4017% | November 3rd, 2023 | 0.4024% |
3 | November 4th, 2024 | 0.6624% | November 4th, 2024 | 0.6633% |
4 | November 2nd, 2026 | 0.9901% | November 3rd, 2026 | 0.9910% |
5 | November 2nd, 2029 | 1.2446% | November 5th, 2029 | 1.2454% |
6 | November 3rd, 2031 | 1.3470% | November 3rd, 2031 | 1.3477% |
7 | November 2nd, 2033 | 1.4085% | November 3rd, 2033 | 1.4091% |
8 | November 3rd, 2036 | 1.5007% | November 3rd, 2036 | 1.5012% |
9 | November 4th, 2041 | 1.5667% | November 4th, 2041 | 1.5671% |
10 | November 2nd, 2046 | 1.5751% | November 5th, 2046 | 1.5754% |
11 | November 2nd, 2051 | 1.5625% | November 3rd, 2051 | 1.5628% |
12 | November 2nd, 2056 | 1.5346% | November 3rd, 2056 | 1.5348% |
13 | November 2nd, 2061 | 1.4751% | November 3rd, 2061 | 1.4753% |
14 | November 2nd, 2066 | 1.3871% | November 3rd, 2066 | 1.3872% |
15 | November 2nd, 2071 | 1.3621% | November 3rd, 2071 | 1.3622% |
The changes are due to the adjustments of the nodes around weekends and holidays; for instance, you can see that the distance between the 2023 and 2024 nodes changed from 368 days to 367, or that the distance between the 2029 and 2031 nodes changed from 731 to 728. This is perfectly fine: is a consequence of having chosed to keep the market quotes constant. However, we might decide to interpret the requirements in a different way.
Same zero rates
In particular, we might choose to keep the curve the same in the sense of translating it rigidly from the old evaluation date to the new one. This means taking the nodes of the old curve, shifting all the dates by the same three days, and keeping the rates the same; finally, we’ll pass them to a curve that uses the same interpolation between rates as the bootstrapped one.
= next_day - today shift
= ql.KrugerZeroCurve(
new_curve + shift for d in todays_rates["node"]],
[d "rate"],
todays_rates[
swap_curve.dayCounter(), )
If we check the new nodes, we can see that the zero rates are the same and so are the distances between nodes. Note that this curve won’t reprice the input swaps exactly; but again, this is ok: it’s a consequence of the legitimate choice of interpreting “keeping everything else the same” as a rigid translation of the curve.
= pd.DataFrame(new_curve.nodes(), columns=["node", "rate"])
new_rates =1).style.format(
pd.concat([todays_rates, new_rates], axis"rate": "{:.4%}"}
{ )
node | rate | node | rate | |
---|---|---|---|---|
0 | October 29th, 2021 | 0.1350% | November 1st, 2021 | 0.1350% |
1 | November 2nd, 2022 | 0.1350% | November 5th, 2022 | 0.1350% |
2 | November 2nd, 2023 | 0.4017% | November 5th, 2023 | 0.4017% |
3 | November 4th, 2024 | 0.6624% | November 7th, 2024 | 0.6624% |
4 | November 2nd, 2026 | 0.9901% | November 5th, 2026 | 0.9901% |
5 | November 2nd, 2029 | 1.2446% | November 5th, 2029 | 1.2446% |
6 | November 3rd, 2031 | 1.3470% | November 6th, 2031 | 1.3470% |
7 | November 2nd, 2033 | 1.4085% | November 5th, 2033 | 1.4085% |
8 | November 3rd, 2036 | 1.5007% | November 6th, 2036 | 1.5007% |
9 | November 4th, 2041 | 1.5667% | November 7th, 2041 | 1.5667% |
10 | November 2nd, 2046 | 1.5751% | November 5th, 2046 | 1.5751% |
11 | November 2nd, 2051 | 1.5625% | November 5th, 2051 | 1.5625% |
12 | November 2nd, 2056 | 1.5346% | November 5th, 2056 | 1.5346% |
13 | November 2nd, 2061 | 1.4751% | November 5th, 2061 | 1.4751% |
14 | November 2nd, 2066 | 1.3871% | November 5th, 2066 | 1.3871% |
15 | November 2nd, 2071 | 1.3621% | November 5th, 2071 | 1.3621% |
If we link this new curve to the handle we’re using, we can get a new bond price and thus a new Theta:
curve_handle.linkTo(new_curve)
bond.cleanPrice()
99.98218981727182
= bond.cleanPrice() - P0
theta_per_day theta_per_day
-0.007086195202134604
Same coupon rates
There is a third possibility. Both approaches so far will cause the expected coupon rates to change, because the curve will shift while the coupon period remains the same. We can check this by going back to the old evaluation date and curve, extracting the rates by using a small helper function, and then going back to the new ones and doing the same.
= today
ql.Settings.instance().evaluationDate curve_handle.linkTo(swap_curve)
def cashflows():
= []
data for cf in bond.cashflows():
= ql.as_coupon(cf)
c if c is None:
None, cf.amount()))
data.append((cf.date(), else:
data.append((c.date(), c.rate(), c.amount()))return pd.DataFrame(data, columns=["date", "rate", "amount"])
= cashflows()
base_cf format({"rate": "{:.4%}", "amount": "{:.4f}"}) base_cf.style.
date | rate | amount | |
---|---|---|---|
0 | November 11th, 2021 | 1.0000% | 50.0000 |
1 | May 11th, 2022 | 0.1351% | 6.7538 |
2 | November 11th, 2022 | 0.1357% | 6.7870 |
3 | May 11th, 2023 | 0.4667% | 23.3328 |
4 | November 11th, 2023 | 0.9142% | 45.7086 |
5 | May 11th, 2024 | 1.1098% | 55.4894 |
6 | November 11th, 2024 | 1.2803% | 64.0170 |
7 | May 11th, 2025 | 1.3579% | 67.8955 |
8 | May 11th, 2025 | nan% | 10000.0000 |
= next_day
ql.Settings.instance().evaluationDate curve_handle.linkTo(new_curve)
= cashflows()
new_cf "rate", "amount"]]], axis=1).style.format(
pd.concat([base_cf, new_cf[["rate": "{:.4%}", "amount": "{:.4f}"}
{ )
date | rate | amount | rate | amount | |
---|---|---|---|---|---|
0 | November 11th, 2021 | 1.0000% | 50.0000 | 1.0000% | 50.0000 |
1 | May 11th, 2022 | 0.1351% | 6.7538 | 0.1351% | 6.7538 |
2 | November 11th, 2022 | 0.1357% | 6.7870 | 0.1354% | 6.7686 |
3 | May 11th, 2023 | 0.4667% | 23.3328 | 0.4567% | 22.8342 |
4 | November 11th, 2023 | 0.9142% | 45.7086 | 0.9111% | 45.5543 |
5 | May 11th, 2024 | 1.1098% | 55.4894 | 1.1051% | 55.2546 |
6 | November 11th, 2024 | 1.2803% | 64.0170 | 1.2796% | 63.9818 |
7 | May 11th, 2025 | 1.3579% | 67.8955 | 1.3554% | 67.7718 |
8 | May 11th, 2025 | nan% | 10000.0000 | nan% | 10000.0000 |
As expected, some of the coupon rates change. What if we built a curve with the new reference date but that returns the same forward rates as the old one over a given period? This would be a third interpretation of “keeping everything else the same”, and an equally sensible one.
To do that, we first create a curve which is the same as the old one but with a reference date that doesn’t move…
= new_curve = ql.KrugerZeroCurve(
base_curve "node"], todays_rates["rate"], swap_curve.dayCounter()
todays_rates[ )
…and then we use the ImpliedTermStructure
class, which returns a curve with a new reference date but with the same expected rates between two dates.
= ql.ImpliedTermStructure(
new_curve
ql.YieldTermStructureHandle(base_curve), next_day )
We can link this new curve to our handle and check that the coupons remain unchanged:
curve_handle.linkTo(new_curve)
= cashflows()
new_cf "rate", "amount"]]], axis=1).style.format(
pd.concat([base_cf, new_cf[["rate": "{:.4%}", "amount": "{:.4f}"}
{ )
date | rate | amount | rate | amount | |
---|---|---|---|---|---|
0 | November 11th, 2021 | 1.0000% | 50.0000 | 1.0000% | 50.0000 |
1 | May 11th, 2022 | 0.1351% | 6.7538 | 0.1351% | 6.7538 |
2 | November 11th, 2022 | 0.1357% | 6.7870 | 0.1357% | 6.7870 |
3 | May 11th, 2023 | 0.4667% | 23.3328 | 0.4667% | 23.3328 |
4 | November 11th, 2023 | 0.9142% | 45.7086 | 0.9142% | 45.7086 |
5 | May 11th, 2024 | 1.1098% | 55.4894 | 1.1098% | 55.4894 |
6 | November 11th, 2024 | 1.2803% | 64.0170 | 1.2803% | 64.0170 |
7 | May 11th, 2025 | 1.3579% | 67.8955 | 1.3579% | 67.8955 |
8 | May 11th, 2025 | nan% | 10000.0000 | nan% | 10000.0000 |
And finally, we can reprice the bond and recalculate a third version of the Theta:
bond.cleanPrice()
99.98207313531329
= bond.cleanPrice() - P0
theta_per_day theta_per_day
-0.007202877160665366
Note that, while the forward rates stay the same, the zero rates corresponding to the coupon dates—that is, the numbers going into the \(\exp(-rT)\) formula to give you discount factors—must change. As the saying goes in Italy, the blanket is short: you can cover your shoulders or your feet, but not both.
= []
data for cf in bond.cashflows():
if cf.date() > bond.settlementDate():
data.append(
(
cf.date(),
base_curve.zeroRate(
cf.date(), base_curve.dayCounter(), ql.Continuous
).rate(),
new_curve.zeroRate(
cf.date(), new_curve.dayCounter(), ql.Continuous
).rate(),
)
)
pd.DataFrame(=["date", "zero rate", "new zero rate"]
data, columnsformat({"zero rate": "{:.5%}", "new zero rate": "{:.5%}"}) ).style.
date | zero rate | new zero rate | |
---|---|---|---|
0 | November 11th, 2021 | 0.13503% | 0.13503% |
1 | May 11th, 2022 | 0.13503% | 0.13503% |
2 | November 11th, 2022 | 0.13535% | 0.13536% |
3 | May 11th, 2023 | 0.24245% | 0.24303% |
4 | November 11th, 2023 | 0.40816% | 0.40927% |
5 | May 11th, 2024 | 0.54499% | 0.54633% |
6 | November 11th, 2024 | 0.66623% | 0.66767% |
7 | May 11th, 2025 | 0.76258% | 0.76404% |
8 | May 11th, 2025 | 0.76258% | 0.76404% |
Choices, choices
So, which one is the right Theta? Well, I’m not sure there is one; the choice will be yours, probably based on how the Theta will be used in your application or on your desks, or on whether it should be compared to figures coming from elsewhere. As usual, any choice will likely be a trade-off.